Move Semantics in C++

1. lvalue and rvalue

在 C 中,我们常看到这样的表达式:int i = 10;。这种式子中,变量 i 是有地址属性的,而数字 10 是一个栈中的临时量,它只有值属性。由于 i 这样拥有地址属性的变量总是出现在等号的左边而 10 这种只有值属性的值总是出现在等号右侧。我们就将这种有地址属性在内存中有存储区域的表达式或变量称作左值(lvalue),而不具有持久存储的值我们称作右值(rvalue)

int i = 1; // okay
int j = i;  // okay
int 1 = j; // error
int k = (i + j); // okay, (i + j) is a rvalue
int (i + j) = k; // error

由于左值有地址属性,所以我们可以对左值取地址,也就是 &i

int i = 10;
int* pi = &i; // okay
int* pi2 = i + i; // error, (i + i) is a rvalue

除了一些字面量,一般而言,函数的非引用返回值也是典型的右值。因为函数的非引用返回一般存储在寄存器中,而寄存器是不能取地址的。

int addValue(int a, int b){
	return a + b;
}
int main(){
	int i = addValue(3, 4); // okay
	addValue() = 5;     // error
}

1.1 lvalue Reference

由左值和右值的概念由引入了左值引用和右值引用的概念。(左值)引用是一个变量的别名。比如我们有以下的程序:

int i = 10;
int& lvref = i; // okay
int& lvref = 10;// error, lvalue ref only bind to the lvalue

在这里,变量 ref 是变量 i 的别名。既然是别名,即这两个变量共用一个地址空间。前面我们提到了右值没有地址属性,所以左值引用对右值的绑定就是非法的。当你运行程序,编译器会提示:cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type ‘int’

既然我们并不能给没有地址属性的右值起一个别名。但为什么如下的用 const 修饰的右值就可以为左值引用所绑定?

const int& lvref = 10;

这是由于编译器会隐式地生成一个临时对象(左值)来存储右值,这被称为 temporary materialization 。所以实际上我们的左值引用是对存储 10 这个右值的左值的绑定。当然,这种方式只是 C++11 之前右值引用没有出现时 C++ 绑定右值的方式。

// as same as:
const int temp = 10;
const int& lvref = temp;

在汇编层面,它们是一样的。

main:

        push    rbp
        mov     rbp, rsp
        mov     dword ptr [rbp - 4], 0
        
		; const int& ref = 10;
        mov     dword ptr [rbp - 20], 10
        lea     rax, [rbp - 20]
        mov     qword ptr [rbp - 16], rax
		
		; const int temp = 10;
		; const int& ref = temp;
        mov     dword ptr [rbp - 24], 10
        lea     rax, [rbp - 24]
        mov     qword ptr [rbp - 32], rax

        xor     eax, eax
        pop     rbp
        ret

在左值引用中,我们最常见的用法就是用左值引用替换掉传统用指针传参的做法。引用和指针汇编后是一样的,但是指针我们需要取地址解引用。而引用完全不需要,你可以把引用理解成一种用户友好化的指针。

#include <iostream>

void printVal(int& val) {
    std::cout << val << std::endl;
}
int main() {
    int x = 1;
    printVal(x);
    printVal(20); // cannot bind non-const lvalue reference to an rvalue
}

同样的,在传递右值(20)的时候,你也需要用 const int& 绑定右值参数。这时,编译器会生成一个临时的 const int 左值对象,并将其绑定到 val 上。

#include <iostream>

void printVal(const int& val) {
    std::cout << val << std::endl;
}
int main() {
    int x = 1;
    printVal(x); // ok
    printVal(20);// ok
}

此外,我们还可以将函数的返回值变成左值引用类型。比如我们有一个左值 val ,让 int& access_Val() 函数返回左值引用。因为左值引用是对已有左值的别名,所以左值引用类型的返回值是可以取地址的,其返回值就相当于 val 变量本身。我们可以用这种方式来 set 或 get 类内变量:

#include <iostream>

class example{
	int val;
public:
	int& access_Val(){
		return val;
	}
};
int main(){
	example e1;
	e1.access_Val() = 10;
    std::cout << &e1.access_Val() << std::endl;
	return 0;
}

通过这种方式,我们实际上让 accessVal() 变成数据双向流向的函数。还可以避免不必要的拷贝。这样,我们可以直接操作类中的成员变量,并且在需要时进行赋值和获取,所有这些操作都避免了不必要的拷贝操作,提高了性能和效率。

1.2 rvalue Reference

在前面,我们发现类似这样的语句是非法的:int& rvref = 10;。编译器会告诉你cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type ‘int’。因为左值引用所引用的对象是一个右值,而左值引用是不能绑定到右值的。

而且我们注意到加上 const 实际上绑定的实际上还是左值。为了实现对右值的绑定,C++11 引入了右值引用 && 。右值引用只能绑定右值,而不能绑定左值:

int&& rvref = 10;

int i = 10;
int&& rvref = i; // cannot bind rvalue reference of type to lvalue

当我们试图去打印 rvref 时,我们可以得到期望的结果。但为什么要这样做?

#include <iostream>

int main(){
	int&& rvref = 10; // difference from `int i = 10;` ?
	int i = rvref;
	std::cout << rvref << std::endl; // prints out the integer 10
	std::cout << &rvref << std:endl; // okay
	std::cout << &i << std:endl;     // okay
	return 0;
}

接着之前的例子:

#include <iostream>

// lvalue ref copy
void printVal(const int& lval) {
    std::cout << lval << std::endl;
}
// rvalue ref no copy
void printVal(int&& rval) {
    std::cout << rval << std::endl;
}
// thus, this is not allowed with whose overloading above:
// void printVal(int val){
//
// } // not allowed
int main() {
    int x = 1;
    printVal(x); // ok, call printVal(const int& lval)
    printVal(20);// ok, call printVal(int&& rval)
}

有了因为右值引用绑定右值,所以当我们在函数里面传递的是右值时,它会调用右值引用的重载函数。我们后面会看到,使用右值引用可以减少不必要的内存拷贝的次数。当对象很大的时候,使用右值引用就可以显著优化资源管理和执行效率。

2. Move Semantics

移动语义是C++11引入的一项特性,右值或右值引用是其最主要的应用。它允许对象的资源(如堆上分配的内存)在不进行深度复制的情况下进行所有权的转移。可以将对象的资源所有权从一个对象转移到另一个对象,从而避免不必要的内存拷贝,提高程序性能和效率。

假如我们有如下程序:

#inlcude <vector>
int main(){
	std::vector<int> v1{ 1, 2, 3, 4, 5 }; 
	std::vector<int> v2{}; 
	v2 = v1;
	return 0;
}

在进行拷贝操作时,标准库容器会进行深拷贝,这就意味着v2会拥有v1的所有资源的拷贝。

Pasted image 20250111194242.png

虽然深拷贝可以避免数据的不一致性,但通常会涉及大量的内存操作,进而造成一定的存储和性能开销。而移动语义通过移动构造函数和移动赋值运算符,可以将资源“移动”到新对象中,而不是复制,从而减少了开销。

2.1 std::move()

std::move 是 C++11 引入的一个标准库函数,std::move()并不移动任何东西。而是将其输入无条件地转换为右值引用(rvalue reference)。这意味着无论传递给 std::move 的是什么类型的对象,它都会被转换为右值引用,从而允许移动语义的应用。

template<typename _Tp>
    _GLIBCXX_NODISCARD
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

为了避免额外的复制开销,C++ 引入了移动语义。在 STL 标准容器库中,移动赋值运算符已经在类内进行了重载、移动构造函数也进行了实现。确保等号的右边是右值的时候可以进行移动赋值。

假如我们有如下程序:

#inlcude <vector>
int main(){
	std::vector<int> v1{ 1, 2, 3, 4, 5 }; 
	std::vector<int> v2{}; 
	v2 = std::move(v1);
	return 0;
}

这个例子中,我们用std::move()v1的资源移动到v2,避免了深拷贝。这个过程图示如下:

  1. v2v1所持有的资源进行引用:
    Pasted image 20250111204014.png

  2. 所有权进行转移,v1不再持有资源,进而重置v1
    Pasted image 20250111204033.png

通过std::move(),资源从v1转移到v2v1不再持有这些资源。移动后的v1处于有效但未定义的状态,不再持有原资源。对于程序员而言,我们需要承诺在所有权转移后不再使用v1

2.2 The Rule of Five

移动语义出现之后,我们想实践对象所有权的转移,我们需要两个特别的新函数:移动构造函数和移动赋值运算符(move constructor and move assignment operator)。从下面的代码中,你可以窥见所有权的转移是怎么样的。

#include <string>
class Widget {
private:
    int i{0};
    std::string s{};
    int* pi{nullptr}; // with unique opinter, you can make move constructor defalut
public:
    Widget(Widget&& w) noexcept
        : i(std::move(w.i)), s(std::move(w.s)), pi(std::move(w.pi)) // member-wise move
    {
        w.pi = nullptr; // Reset
    }
    Widget& operator=(Widget&& w) noexcept {
        if (this != &w) {
            delete pi; // Clean-up
            i = std::move(w.i); // Member-wise move
            s = std::move(w.s); // Member-wise move
            pi = std::move(w.pi); // Member-wise move
            w.pi = nullptr; // Reset
        }
        return *this;
    }
};
/*
#include <string>
#include <memory>

class Widget {
private: 
    int i{0};
    std::string s{};
    std::unique_ptr<int> pi{nullptr};
public:
    Widget(Widget&& w) = default; // noexcept by default
    Widget& operator=(Widget&& w) = default; // noexcept by default
};
*/

为了避免不必要的拷贝操作,“五法则”成为了 class designing 的最佳实践。然而,随着 C++11 引入了移动语义和智能指针等特性,“零法则”逐渐成为一种更理想的设计哲学。

“零法则”提倡不要手动定义任何特殊成员函数,而是依赖于编译器生成的默认实现。通过组合已有的类和资源管理器(例如 std::unique_ptrstd::shared_ptr),我们可以在需要时自动拥有正确的拷贝或移动行为。

// The Rule of Zero
#include <memory>

class myClass {
private:
    std::unique_ptr<int> data;
public:
    myClass() = default;
    ~myClass() = default;
    // No declaration of copy/move constructor or copy/move assignment operator
};

2.3 Move Semantics by Example

例如,考虑一个简单的类 MyString,它包含一个指向字符数组的指针。在使用移动构造函数时,新的 MyString 对象会接管原对象的指针,而原对象的指针会被置为空指针,这样在销毁原对象时就不会重复释放内存。

#include <iostream>
#include <string.h>

class String{
private:
   char* m_Data;
   uint32_t m_Size;
public:
    String() = default;
    String(const char* string){
        m_Size = strlen(string);
        m_Data = new char[m_Size];
        memcpy(m_Data, string, m_Size);
        std::cout << "String created!" << std::endl;
    }
    String(const String& other){
        m_Size = other.m_Size;
        m_Data = new char[m_Size];
        memcpy(m_Data, other.m_Data, m_Size);
        std::cout << "String copied!" << std::endl;
    }
    String(String&& other) noexcept{
        m_Size = other.m_Size;
        m_Data = other.m_Data;
        other.m_Size = 0;
        other.m_Data = nullptr;
        std::cout << "String Moved!" << std::endl;
    }
    ~String(){
        delete m_Data;
        std::cout << "String destroyed!" << std::endl;
    }
    void Print(){
        for (uint32_t i = 0; i < m_Size; i++)
        {
            printf("%c", m_Data[i]);
        }
    }
};
class Entity{
private:
    String s_Name;
public:
    Entity(const String& name) : s_Name(name){}
    Entity(String&& name) : s_Name((String&&)name){}

    void printName(){
        s_Name.Print();
    }
};

int main(){

    Entity entity("Hello\n");
    entity.printName();
    std::cin.get();
    return 0;
}

在这个例子中,MyString 的移动构造函数通过 std::move 将资源从一个对象转移到另一个对象,从而避免了不必要的复制操作。